From ff6b84ff8818ee96f1d6cf479f72b2127eddec43 Mon Sep 17 00:00:00 2001
From: Brandon Eng <bce25@cornell.edu>
Date: Wed, 17 Jan 2024 21:42:00 +0700
Subject: Group expenses (#48)

* Group expenses my date

* Group expenses my date

* typescript errors

* prettier

* getExpenseGroup

* update logic to use dayjs

* clean up
---
 src/app/groups/[groupId]/expenses/expense-list.tsx | 177 ++++++++++++++-------
 1 file changed, 117 insertions(+), 60 deletions(-)

(limited to 'src/app/groups/[groupId]/expenses/expense-list.tsx')

diff --git a/src/app/groups/[groupId]/expenses/expense-list.tsx b/src/app/groups/[groupId]/expenses/expense-list.tsx
index 9217123..1a36179 100644
--- a/src/app/groups/[groupId]/expenses/expense-list.tsx
+++ b/src/app/groups/[groupId]/expenses/expense-list.tsx
@@ -3,7 +3,8 @@ import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
 import { Button } from '@/components/ui/button'
 import { getGroupExpenses } from '@/lib/api'
 import { cn } from '@/lib/utils'
-import { Participant } from '@prisma/client'
+import { Expense, Participant } from '@prisma/client'
+import dayjs, { type Dayjs } from 'dayjs'
 import { ChevronRight } from 'lucide-react'
 import Link from 'next/link'
 import { useRouter } from 'next/navigation'
@@ -16,6 +17,46 @@ type Props = {
   groupId: string
 }
 
+const EXPENSE_GROUPS = {
+  THIS_WEEK: 'This week',
+  EARLIER_THIS_MONTH: 'Earlier this month',
+  LAST_MONTH: 'Last month',
+  EARLIER_THIS_YEAR: 'Earlier this year',
+  LAST_YEAR: 'Last year',
+  OLDER: 'Older',
+}
+
+function getExpenseGroup(date: Dayjs, today: Dayjs) {
+  if (today.isSame(date, 'week')) {
+    return EXPENSE_GROUPS.THIS_WEEK
+  } else if (today.isSame(date, 'month')) {
+    return EXPENSE_GROUPS.EARLIER_THIS_MONTH
+  } else if (today.subtract(1, 'month').isSame(date, 'month')) {
+    return EXPENSE_GROUPS.LAST_MONTH
+  } else if (today.isSame(date, 'year')) {
+    return EXPENSE_GROUPS.EARLIER_THIS_YEAR
+  } else if (today.subtract(1, 'year').isSame(date, 'year')) {
+    return EXPENSE_GROUPS.LAST_YEAR
+  } else {
+    return EXPENSE_GROUPS.OLDER
+  }
+}
+
+function getGroupedExpensesByDate(
+  expenses: Awaited<ReturnType<typeof getGroupExpenses>>,
+) {
+  const today = dayjs()
+  return expenses.reduce(
+    (result: { [key: string]: Expense[] }, expense: Expense) => {
+      const expenseGroup = getExpenseGroup(dayjs(expense.expenseDate), today)
+      result[expenseGroup] = result[expenseGroup] ?? []
+      result[expenseGroup].push(expense)
+      return result
+    },
+    {},
+  )
+}
+
 export function ExpenseList({
   expenses,
   currency,
@@ -44,67 +85,83 @@ export function ExpenseList({
   const getParticipant = (id: string) => participants.find((p) => p.id === id)
   const router = useRouter()
 
+  const groupedExpensesByDate = getGroupedExpensesByDate(expenses)
+
   return expenses.length > 0 ? (
-    expenses.map((expense) => (
-      <div
-        key={expense.id}
-        className={cn(
-          'border-t flex justify-between px-4 sm:pr-2 sm:pl-6 py-4 text-sm cursor-pointer hover:bg-accent gap-1 items-stretch',
-          expense.isReimbursement && 'italic',
-        )}
-        onClick={() => {
-          router.push(`/groups/${groupId}/expenses/${expense.id}/edit`)
-        }}
-      >
-        <CategoryIcon
-          category={expense.category}
-          className="w-4 h-4 mr-2 mt-0.5 text-muted-foreground"
-        />
-        <div className="flex-1">
-          <div className={cn('mb-1', expense.isReimbursement && 'italic')}>
-            {expense.title}
-          </div>
-          <div className="text-xs text-muted-foreground">
-            Paid by <strong>{getParticipant(expense.paidById)?.name}</strong>{' '}
-            for{' '}
-            {expense.paidFor.map((paidFor, index) => (
-              <Fragment key={index}>
-                {index !== 0 && <>, </>}
-                <strong>
-                  {
-                    participants.find((p) => p.id === paidFor.participantId)
-                      ?.name
-                  }
-                </strong>
-              </Fragment>
-            ))}
-          </div>
-        </div>
-        <div className="flex flex-col justify-between items-end">
-          <div
-            className={cn(
-              'tabular-nums whitespace-nowrap',
-              expense.isReimbursement ? 'italic' : 'font-bold',
-            )}
-          >
-            {currency} {(expense.amount / 100).toFixed(2)}
-          </div>
-          <div className="text-xs text-muted-foreground">
-            {formatDate(expense.expenseDate)}
+    Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => {
+      const groupExpenses = groupedExpensesByDate[expenseGroup]
+      if (!groupExpenses) return null
+      return (
+        <Fragment key={expenseGroup}>
+          <div className={'border-t text-md pl-3 py-2 font-semibold'}>
+            {expenseGroup}
           </div>
-        </div>
-        <Button
-          size="icon"
-          variant="link"
-          className="self-center hidden sm:flex"
-          asChild
-        >
-          <Link href={`/groups/${groupId}/expenses/${expense.id}/edit`}>
-            <ChevronRight className="w-4 h-4" />
-          </Link>
-        </Button>
-      </div>
-    ))
+          {groupExpenses.map((expense: any) => (
+            <div
+              key={expense.id}
+              className={cn(
+                'border-t flex justify-between px-4 sm:pr-2 sm:pl-6 py-4 text-sm cursor-pointer hover:bg-accent gap-1 items-stretch',
+                expense.isReimbursement && 'italic',
+              )}
+              onClick={() => {
+                router.push(`/groups/${groupId}/expenses/${expense.id}/edit`)
+              }}
+            >
+              <CategoryIcon
+                category={expense.category}
+                className="w-4 h-4 mr-2 mt-0.5 text-muted-foreground"
+              />
+              <div className="flex-1">
+                <div
+                  className={cn('mb-1', expense.isReimbursement && 'italic')}
+                >
+                  {expense.title}
+                </div>
+                <div className="text-xs text-muted-foreground">
+                  Paid by{' '}
+                  <strong>{getParticipant(expense.paidById)?.name}</strong> for{' '}
+                  {expense.paidFor.map((paidFor: any, index: number) => (
+                    <Fragment key={index}>
+                      {index !== 0 && <>, </>}
+                      <strong>
+                        {
+                          participants.find(
+                            (p) => p.id === paidFor.participantId,
+                          )?.name
+                        }
+                      </strong>
+                    </Fragment>
+                  ))}
+                </div>
+              </div>
+              <div className="flex flex-col justify-between items-end">
+                <div
+                  className={cn(
+                    'tabular-nums whitespace-nowrap',
+                    expense.isReimbursement ? 'italic' : 'font-bold',
+                  )}
+                >
+                  {currency} {(expense.amount / 100).toFixed(2)}
+                </div>
+                <div className="text-xs text-muted-foreground">
+                  {formatDate(expense.expenseDate)}
+                </div>
+              </div>
+              <Button
+                size="icon"
+                variant="link"
+                className="self-center hidden sm:flex"
+                asChild
+              >
+                <Link href={`/groups/${groupId}/expenses/${expense.id}/edit`}>
+                  <ChevronRight className="w-4 h-4" />
+                </Link>
+              </Button>
+            </div>
+          ))}
+        </Fragment>
+      )
+    })
   ) : (
     <p className="px-6 text-sm py-6">
       Your group doesn’t contain any expense yet.{' '}
-- 
cgit v1.3

